Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat[venom]: add loop invariant hoisting pass #4175

Open
wants to merge 19 commits into
base: master
Choose a base branch
from

Conversation

HodanPlodky
Copy link
Contributor

@HodanPlodky HodanPlodky commented Jul 11, 2024

What I did

Loop invariant hoisting

How I did it

Implemented loop detection analysis and IR pass which. The detection is done via dfs. The pass detects which instruction are not dependent on instruction within the loop and if not then the instruction is hoisted to the basic block before the loop. This is done iteratively until there are no such instruction in any loop

How to verify it

Commit message

feat[venom]: Loop invariant hoisting

Commit message for the final, squashed PR. (Optional, but reviewers will appreciate it! Please see our commit message style guide for what we would ideally like to see in a commit message.)

Description for the changelog

Cute Animal Picture

Cute rabbit

def analyze(self):
self.analyses_cache.request_analysis(CFGAnalysis)
self.loops: dict[IRBasicBlock, list[IRBasicBlock]] = dict()
done: dict[IRBasicBlock, bool] = dict(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We usually use a set for this purpose, and you will see that we also tent to use our own OrderedSet implementation even when really not visibly necessary, to avoid ending up with some weird non-deterministic code generation.

def invalidate(self):
return super().invalidate()

def dfs(self, bb: IRBasicBlock, done: dict[IRBasicBlock, bool], path: list[IRBasicBlock]):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couldn't this done be promoted to a class member?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it could. Should it be promoted even though it is only used for dfs method and is reset before every run of analyze and is not necessary for consumer of this analysis?

from vyper.venom.basicblock import IRBasicBlock


class LoopDetection(IRAnalysis):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As a convention we end all analysis names in Analysis

@HodanPlodky HodanPlodky marked this pull request as ready for review July 15, 2024 11:39
self.dfs(entry)

def invalidate(self):
return super().invalidate()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no need for this implementation

def invalidate(self):
return super().invalidate()

def dfs(self, bb: IRBasicBlock, before: IRBasicBlock = None):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this function does loop detection, why name it dfs?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: we use the convention foo_r() for recursive functions, and prefix with underscore (i.e. _foo_r()) for "private" functions (those that are not called externally)

self.done.add(bb)
return

def collect_path(self, bb_from: IRBasicBlock, bb_to: IRBasicBlock) -> OrderedSet[IRBasicBlock]:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: _collect_path

self.collect_path_inner(bb_from, bb_to, loop, collect_visit)
return loop

def collect_path_inner(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_collect_path_inner()

def _get_hoistable_loop(
self, from_bb: IRBasicBlock, loop: OrderedSet[IRBasicBlock]
) -> list[tuple[IRBasicBlock, IRBasicBlock, IRInstruction]]:
result: list[tuple[IRBasicBlock, IRBasicBlock, IRInstruction]] = []
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these type signatures are hard to read -- either make them less specific (e.g. -> list or -> list[tuple]) or make a small dataclass local to this file to carry around the data structure.

return True
return False

def _can_hoist_instruction_ignore_stores(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

leave a comment explaining why this is necessary?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe just call this _can_hoist_instruction (or just _can_hoist!), as it doesn't need to be contrasted with a function like _can_hoist_instruction_not_ignoring_stores.

for in_var in instruction.get_input_variables():
assert isinstance(in_var, IRVariable)
source_ins = self.dfg._dfg_outputs[in_var]
if source_ins in bb.instructions:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think we can just check inst.parent for each instruction here

break
# I have this inside the loop because I dont need to
# invalidate if you dont hoist anything
self.analyses_cache.invalidate_analysis(LivenessAnalysis)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let's bring this outside the loop, and add a flag inside the loop to detect if invalidate_analysis should be called

def run_pass(self):
self.analyses_cache.request_analysis(CFGAnalysis)
self.dfg = self.analyses_cache.request_analysis(DFGAnalysis)
loops = self.analyses_cache.request_analysis(LoopDetectionAnalysis)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

loops.loops looks weird, maybe just name this t? or loops_analysis

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

loops_analysis 👍

Any VOLATILE_INSTRUCTIONS, BB_TERMINATORS CFG_ALTERING_INSTRUCTIONS are ignored
"""

from typing import Iterator
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no need for local import, please hoist this to top of file

def _is_correct_store(instruction: IRInstruction) -> bool:
return (
instruction.opcode == "store"
and len(instruction.operands) == 1
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this condition is always true, no?

for in_var in instruction.get_input_variables():
assert isinstance(in_var, IRVariable)
source_ins = self.dfg._dfg_outputs[in_var]
if _is_correct_store(source_ins):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how can this condition ever hold true?

assert before is not None, "Loop must have one basic block before it"
loop = self._collect_path(before, bb)
in_bb = bb.cfg_in.difference({before})
assert len(in_bb) == 1, "Loop must have one input basic block"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Since we are looking for natural loops, we should probably rename the analysis to NaturalLoopDetectionAnalysis
  • We should also probably not assert if the loop was not natural, just ignore that loop

self._dfs_r(neighbour, bb)

self.done.add(bb)
return
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

redundant

)


def _is_correct_store(instruction: IRInstruction) -> bool:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is basically _is_store()

def _can_hoist_instruction_ignore_stores(
self, instruction: IRInstruction, loop: OrderedSet[IRBasicBlock]
) -> bool:
if _ignore_instruction(instruction) or _is_correct_store(instruction):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_ignore_instruction() includes testing for store as it returns True for volatiles. Just an _ignore_instructions() is better.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the volatiles are only sstore, istore, tstore and mstore, not store. because of that I thought the check for store is necessary or did I miss something?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh snap... you are right. I had that one named set initially, never got over renaming it :D.. let's just tack that test in the _ignore_instruction()?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok I will add it there

return False
return True

def _dependant_in_bb(self, instruction: IRInstruction, bb: IRBasicBlock):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sp: dependent

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think this can be a free function rather than a method, as i don't see any references to self in the body

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The DFGAnalysis from self

def _dependant_in_bb(self, instruction: IRInstruction, bb: IRBasicBlock):
for in_var in instruction.get_input_variables():
assert isinstance(in_var, IRVariable)
source_ins = self.dfg._dfg_outputs[in_var]
Copy link
Member

@charles-cooper charles-cooper Jul 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prefer instruction to always be abbreviated as inst, and also to use the public interface of the dfg analysis, for example:

prev_inst = self.dfg.get_producing_instruction(in_var)
# can add `assert prev_inst is not None` if desired

self.analyses_cache.request_analysis(CFGAnalysis)
self.dfg = self.analyses_cache.request_analysis(DFGAnalysis)
loops = self.analyses_cache.request_analysis(NaturalLoopDetectionAnalysis)
self.loop_analysis = loops.loops
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it's not an analysis anymore so maybe it should just be self.loops = loops.loops.


def _collect_path_r(
self,
act_bb: IRBasicBlock,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

does "act_bb" stand for "active bb"? maybe cur_bb or bb_from or just bb would be better.

act_bb: IRBasicBlock,
bb_to: IRBasicBlock,
loop: OrderedSet[IRBasicBlock],
collect_visit: OrderedSet[IRBasicBlock],
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe just visited

self.dfg = self.analyses_cache.request_analysis(DFGAnalysis)
loops = self.analyses_cache.request_analysis(NaturalLoopDetectionAnalysis)
self.loop_analysis = loops.loops
invalidate_dependant = False
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dependent what? maybe rename to just invalidate

for inst in hoistable:
bb = inst.parent
bb.remove_instruction(inst)
target_bb.insert_instruction(inst, index=len(target_bb.instructions) - 1)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe index does not need to be specified in this case

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am modifying already terminated basic block so I it would crash on assert if the index would not be specified

if before is None:
return
loop = self._collect_path(before, bb)
in_bb = bb.cfg_in.difference({before})
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think this might be clearer as if before in bb.cfg_in: .... does that introduce any bugs?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just because it checks for natural loops, so I check if it has only one input into the loop.

for var in inst.get_input_variables():
source_inst = self.dfg.get_producing_instruction(var)
assert isinstance(source_inst, IRInstruction)
if _is_store(source_inst):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reduce nesting:

if not _is_store(...):
    continue

Copy link

codecov bot commented Aug 10, 2024

Codecov Report

Attention: Patch coverage is 95.93496% with 5 lines in your changes missing coverage. Please review.

Project coverage is 88.95%. Comparing base (5067b86) to head (788bd0d).
Report is 15 commits behind head on master.

Files Patch % Lines
vyper/venom/passes/loop_invariant_hosting.py 95.00% 3 Missing and 1 partial ⚠️
vyper/venom/analysis/loop_detection.py 97.56% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #4175      +/-   ##
==========================================
- Coverage   91.34%   88.95%   -2.40%     
==========================================
  Files         109      111       +2     
  Lines       15606    15758     +152     
  Branches     3432     3469      +37     
==========================================
- Hits        14256    14018     -238     
- Misses        920     1232     +312     
- Partials      430      508      +78     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants